SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
17.7 KB · 274 lines tsx
Raw Blame History
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import { PRIORITY_LABELS } from "@websensor/core/client";5import { EventRow } from "@/components/event-row";6import { Badge, Bar, Chip, Empty, ExtLink, Flag, HealthPill, PageHeader, Panel, Sparkline, Stat, Table, Td, TierBadge } from "@/components/ui";7import { api, type SensorRow } from "@/lib/api";8import { fmtDuration, fmtInt, fmtMs, fmtPct, fmtScore, relTime, typeLabel, untilTime, utcDateTime } from "@/lib/format";910export const dynamic = "force-dynamic";1112export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {13  const { id } = await params;14  const d = await api.source(id);15  if (!d) return { title: "Source not found" };16  return { title: `${d.source.name} — source intelligence`, description: d.source.description ?? `Sensors, quality, activity and events for ${d.source.name} (${d.source.domain}).`, alternates: { canonical: `/source/${d.source.id}` } };17}1819function qualityTone(v: number): "signal" | "warn" | "hot" | undefined {20  return v >= 80 ? "signal" : v >= 60 ? undefined : v >= 40 ? "warn" : "hot";21}2223/** Source intelligence page (spec §27, §48). */24export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {25  const { id } = await params;26  const d = await api.source(id);27  if (!d) notFound();28  const events = await api.events({ source: d.source.id, limit: 40 });29  const src = d.source;30  const a = d.activity ?? {};31  const q = d.quality;32  const score = a.activity_score ?? 0;33  const entityId = d.entities.find((e) => e.type === "organization" || e.id.startsWith("org_"))?.id;34  const daily = [...(d.daily ?? [])].sort((x, y) => x.day.localeCompare(y.day)).slice(-30);35  const sensorsUp = d.sensors.filter((s) => s.enabled && s.health === "UP").length;36  const rawTotal = q?.raw_changes ?? d.sensors.reduce((n, s) => n + (s.raw_changes ?? 0), 0);37  const meaningfulTotal = q?.meaningful_changes ?? d.sensors.reduce((n, s) => n + (s.meaningful_changes ?? 0), 0);38  const maxType = Math.max(1, ...(d.by_type ?? []).map((t) => t.n));39  const extra = src as typeof src & { llm_enabled?: boolean; discover?: Record<string, boolean>; fallback?: Record<string, boolean>; rate_limit_per_min?: number | null; terms_reviewed_at?: string | null };40  return (41    <>42      <PageHeader43        compact44        kicker={45          <span className="flex flex-wrap items-center gap-2">46            <TierBadge tier={src.tier} />47            <span>Tier {src.tier}</span>48            <span className="text-fg-subtle">·</span>49            <Link href={`/domain/${src.domain}`} className="font-mono hover:underline">{src.domain}</Link>50            {src.country && (51              <>52                <span className="text-fg-subtle">·</span>53                <Link href={`/country/${src.country.toLowerCase()}`} className="inline-flex items-center gap-1 font-mono hover:text-fg"><Flag code={src.country} /> {src.country}</Link>54              </>55            )}56            {src.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />}57            {src.enabled === false && <Chip tone="warn" className="font-mono">DISABLED</Chip>}58          </span>59        }60        title={src.name}61        description={src.description ?? (src.first_party === false ? `Media / aggregator source: its reports are third-party evidence and never count as first-party confirmation.` : `Official channels of ${src.name}: every sensor below is an endpoint the organization itself publishes.`)}62        actions={63          <>64            {src.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}65            {entityId && <Link href={`/entity/${entityId}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Entity & timeline →</Link>}66            <Link href={`/live?q=${encodeURIComponent(src.name)}`} className="hidden rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong sm:inline-block">Live →</Link>67            <ExtLink href={src.homepage ?? `https://${src.domain}`} className="text-[12px]">Website ↗</ExtLink>68          </>69        }70      />7172      <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0">73        <Stat label="Quality score" value={q ? fmtScore(q.quality_score) : "—"} tone={q ? qualityTone(q.quality_score) : undefined} hint={q ? `${q.sensors_up ?? sensorsUp} / ${q.sensors ?? d.sensors.length} sensors up` : "not computed yet"} />74        <Stat label="Success rate" value={q ? fmtPct(q.success_rate) : "—"} tone={q && q.success_rate < 0.9 ? "warn" : undefined} hint={q?.checks_7d ? `${fmtInt(q.checks_7d)} checks · ${fmtInt(q.errors_7d)} errors · 7 d` : `${fmtInt(q?.total_runs)} checks total`} />75        <Stat label="Avg latency" value={fmtMs(q?.avg_latency_ms)} hint="across sensors" />76        <Stat label="Structured" value={q ? fmtPct(q.structured_share) : "—"} hint={q ? `${q.structured_sensors ?? 0} feeds / APIs / status` : undefined} />77        <Stat label="Usefulness" value={q ? fmtPct(q.usefulness) : "—"} hint={`${fmtInt(meaningfulTotal)} meaningful / ${fmtInt(rawTotal)} raw`} />78        <Stat label="Sensors" value={<span>{sensorsUp}<span className="text-fg-subtle"> / {d.sensors.length}</span></span>} hint={`${d.sensors.filter((s) => s.health !== "UP").length} not UP`} tone={sensorsUp < d.sensors.length ? "warn" : undefined} />79      </div>8081      <div className="grid gap-4 xl:grid-cols-[1fr_320px]">82        <div className="flex min-w-0 flex-col gap-4">83          <Panel title="30 days · daily activity" action={daily.length ? <span className="text-[11px] text-fg-subtle">{daily[0]!.day} → {daily[daily.length - 1]!.day}</span> : undefined}>84            {daily.length ? (85              <div className="grid gap-4 sm:grid-cols-3">86                {(87                  [88                    { label: "Checks", key: "checks", tone: "muted" },89                    { label: "Raw changes", key: "raw_changes", tone: "info" },90                    { label: "Events", key: "events", tone: "signal" },91                  ] as const92                ).map((s) => {93                  const values = daily.map((r) => Number(r[s.key] ?? 0));94                  const sum = values.reduce((x, y) => x + y, 0);95                  return (96                    <div key={s.key} className="min-w-0">97                      <div className="flex items-baseline justify-between">98                        <span className="label">{s.label}</span>99                        <span className="font-mono text-[12px] tabular">{fmtInt(sum)}</span>100                      </div>101                      <div className="mt-1 w-full overflow-hidden">102                        <Sparkline values={values} width={300} height={40} tone={s.tone} />103                      </div>104                    </div>105                  );106                })}107              </div>108            ) : (109              <Empty>Daily counters accumulate from the first check onward — nothing recorded for the last 30 days yet.</Empty>110            )}111          </Panel>112113          <Panel title={`Sensors · ${d.sensors.length}`} dense action={<span className="hidden text-[11px] text-fg-subtle sm:inline">P0 critical · P1 major · P2 specialized · P3 low urgency</span>}>114            {d.sensors.length === 0 ? (115              <Empty>No sensors yet — discovery validates feeds, sitemaps and status pages before creating sensors.</Empty>116            ) : (117              <Table head={["Sensor", "Type", "Tier", "Prio", "Status", "Health", "Polling", "Last / next", "HTTP", "Cond.", "24 h", "Snap.", "304 %", "Raw / mean.", ""]}>118                {d.sensors.map((s) => <SensorTr key={s.id} s={s} />)}119              </Table>120            )}121          </Panel>122123          <Panel title="Recent events" dense action={<Link href={`/live?q=${encodeURIComponent(src.name)}`} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}>124            {events.items.length ? events.items.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No meaningful events yet for this source.</Empty>}125          </Panel>126127          {d.discovery.length > 0 && (128            <Panel title={`Discovered endpoints · ${d.discovery.length}`} dense>129              <Table head={["Kind", "URL", "Evidence", "Items", "Value", "Status", "Found"]}>130                {d.discovery.map((c) => (131                  <tr key={c.url} className="hover:bg-panel-2/60">132                    <Td mono>{c.kind}</Td>133                    <Td><span className="block max-w-[28rem] truncate font-mono text-[11.5px]" title={c.url}>{c.url}</span></Td>134                    <Td className="text-fg-subtle">{c.evidence}</Td>135                    <Td mono>{c.score?.itemCount ?? "—"}</Td>136                    <Td mono>{c.score?.value !== undefined ? fmtScore((c.score.value ?? 0) * 100) : "—"}</Td>137                    <Td><Chip tone={c.status === "promoted" ? "ok" : c.status === "rejected" ? "danger" : "default"}>{c.status}</Chip></Td>138                    <Td mono className="whitespace-nowrap text-fg-subtle">{relTime(c.found_at)}</Td>139                  </tr>140                ))}141              </Table>142            </Panel>143          )}144        </div>145146        <aside className="flex min-w-0 flex-col gap-4">147          <Panel title="Activity anomaly">148            <div className="flex items-baseline justify-between">149              <span className={`font-mono text-3xl font-semibold tabular ${score >= 70 ? "text-hot" : score >= 45 ? "text-high" : "text-fg"}`}>{fmtScore(score)}</span>150              <span className="label">{score >= 70 ? "ACTIVITY ANOMALY" : score >= 45 ? "ELEVATED" : "NORMAL"}</span>151            </div>152            <div className="mt-2"><Bar value={score} tone={score >= 70 ? "hot" : score >= 45 ? "high" : "signal"} /></div>153            <dl className="mt-3 grid grid-cols-2 gap-y-1 text-[12.5px]">154              <dt className="text-fg-subtle">Normal</dt><dd className="text-right font-mono tabular">{a.baseline_changes_per_day ?? 0} changes/day</dd>155              <dt className="text-fg-subtle">Current</dt><dd className="text-right font-mono tabular">{a.changes_2h ?? 0} in 2 h</dd>156              <dt className="text-fg-subtle">Events 24 h</dt><dd className="text-right font-mono tabular">{a.events_24h ?? 0}</dd>157              <dt className="text-fg-subtle">Events 14 d</dt><dd className="text-right font-mono tabular">{a.events_14d ?? 0}</dd>158              <dt className="text-fg-subtle">Silent 24 h</dt><dd className={`text-right font-mono tabular ${a.silent_24h ? "text-silent" : ""}`}>{a.silent_24h ?? 0}</dd>159              <dt className="text-fg-subtle">Breaking 24 h</dt><dd className={`text-right font-mono tabular ${a.breaking_24h ? "text-hot" : ""}`}>{a.breaking_24h ?? 0}</dd>160            </dl>161          </Panel>162163          <Panel title="Events by type" dense>164            {d.by_type?.length ? (165              <ul className="divide-y divide-line">166                {d.by_type.map((t) => (167                  <li key={t.event_type} className="grid grid-cols-[8.5rem_1fr_3rem] items-center gap-2 px-3 py-1.5 text-[12.5px]">168                    <Link href={`/live?event_type=${t.event_type}&q=${encodeURIComponent(src.name)}`} className="truncate hover:underline">{typeLabel(t.event_type)}</Link>169                    <Bar value={t.n} max={maxType} tone="info" />170                    <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span>171                  </li>172                ))}173              </ul>174            ) : (175              <Empty>No events yet.</Empty>176            )}177          </Panel>178179          <Panel title={`Entities · ${d.entities.length}`} dense>180            {d.entities.length ? (181              <ul className="divide-y divide-line">182                {d.entities.map((e) => (183                  <li key={e.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[13px]">184                    <Link href={`/entity/${e.id}`} className="min-w-0 truncate hover:underline">{e.name}</Link>185                    <span className="whitespace-nowrap font-mono text-[11px] text-fg-subtle">{e.type.replace(/_/g, " ")} · {e.event_count} ev</span>186                  </li>187                ))}188              </ul>189            ) : (190              <Empty>No entities linked.</Empty>191            )}192          </Panel>193194          <Panel title="Source policy">195            <dl className="grid grid-cols-2 gap-y-1 text-[12.5px]">196              <dt className="text-fg-subtle">Provenance</dt><dd className="text-right">{src.first_party === false ? "third-party media" : "first-party channels"}</dd>197              <dt className="text-fg-subtle">robots.txt checked</dt><dd className="text-right font-mono tabular" title={src.robots_checked_at ? utcDateTime(src.robots_checked_at) : undefined}>{src.robots_checked_at ? relTime(src.robots_checked_at) : "—"}</dd>198              <dt className="text-fg-subtle">Importance weight</dt><dd className="text-right font-mono tabular">{src.importance_weight ?? 1}</dd>199              {extra.llm_enabled !== undefined && (200                <>201                  <dt className="text-fg-subtle">LLM interpretation</dt><dd className="text-right font-mono">{extra.llm_enabled ? "on" : "off · heuristics only"}</dd>202                </>203              )}204              {extra.rate_limit_per_min ? (205                <>206                  <dt className="text-fg-subtle">Rate limit</dt><dd className="text-right font-mono tabular">{extra.rate_limit_per_min}/min</dd>207                </>208              ) : null}209              {extra.discover && Object.values(extra.discover).some(Boolean) && (210                <>211                  <dt className="text-fg-subtle">Discovery</dt><dd className="text-right font-mono">{Object.entries(extra.discover).filter(([, v]) => v).map(([k]) => k).join(" · ")}</dd>212                </>213              )}214              {extra.fallback && Object.values(extra.fallback).some(Boolean) && (215                <>216                  <dt className="text-fg-subtle">Fallback</dt><dd className="text-right font-mono">{Object.entries(extra.fallback).filter(([, v]) => v).map(([k]) => k).join(" · ")}</dd>217                </>218              )}219              <dt className="text-fg-subtle">Acquisition</dt><dd className="text-right">official feeds first</dd>220            </dl>221            {src.notes && <p className="mt-3 border-t border-line pt-2 text-[11.5px] leading-snug text-fg-muted break-words">{src.notes}</p>}222          </Panel>223        </aside>224      </div>225    </>226  );227}228229function SensorTr({ s }: { s: SensorRow }) {230  const runs = s.total_runs ?? 0;231  const nm = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : null;232  const prio = s.priority ?? null;233  const cond = [s.has_etag || s.etag ? "etag" : null, s.has_last_modified || s.last_modified ? "lm" : null].filter(Boolean);234  const condTitle = [s.etag ? `ETag: ${s.etag}` : null, s.last_modified ? `Last-Modified: ${s.last_modified}` : null].filter(Boolean).join("\n") || "No conditional-request validators exposed";235  return (236    <tr className="hover:bg-panel-2/60">237      <Td>238        <Link href={`/sensor/${s.id}`} className="font-medium hover:underline">{s.name}</Link>239        <div className="max-w-[16rem] truncate font-mono text-[11px] text-fg-subtle" title={s.url}>{s.url}</div>240      </Td>241      <Td mono><span className="text-fg-muted">{s.type}</span><div className="text-[10.5px] text-fg-subtle">{s.connector}</div></Td>242      <Td><TierBadge tier={s.tier} /></Td>243      <Td>{prio !== null ? <Chip tone={prio === 0 ? "hot" : prio === 1 ? "high" : "default"} className="font-mono" title={PRIORITY_LABELS[prio] ?? `Priority ${prio}`}>P{prio}</Chip> : <span className="text-fg-subtle">—</span>}</Td>244      <Td>{s.status ? <HealthPill health={s.enabled ? s.status : "DISABLED"} /> : <span className="text-fg-subtle">—</span>}</Td>245      <Td><HealthPill health={s.enabled ? s.health : "DISABLED"} /></Td>246      <Td mono className="whitespace-nowrap">247        {s.current_interval_seconds ? fmtDuration(s.current_interval_seconds) : <span className="text-fg-subtle">adaptive</span>}248        <div className="text-[10.5px] text-fg-subtle">{s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "no base"}</div>249      </Td>250      <Td mono className="whitespace-nowrap text-fg-subtle">251        <div title={s.last_check_at ? utcDateTime(s.last_check_at) : undefined}>{s.last_check_at ? relTime(s.last_check_at) : "never"}</div>252        <div className="text-[10.5px]" title={s.next_check_at ? utcDateTime(s.next_check_at) : undefined}>{untilTime(s.next_check_at)}</div>253      </Td>254      <Td mono>255        {s.last_status ? <span className={s.last_status >= 500 ? "text-danger" : s.last_status >= 400 ? "text-warn" : s.last_status === 304 ? "text-fg-subtle" : ""}>{s.last_status}</span> : <span className="text-fg-subtle">—</span>}256        {s.last_error ? <div className="max-w-[10rem] truncate text-[10.5px] text-danger" title={s.last_error}>{s.last_error}</div> : null}257      </Td>258      <Td>259        <span className="flex gap-1" title={condTitle}>260          {cond.length ? cond.map((c) => <Chip key={c} tone="ok" className="font-mono">{c}</Chip>) : <span className="text-fg-subtle">—</span>}261        </span>262      </Td>263      <Td mono className="whitespace-nowrap">264        {fmtInt(s.checks_24h ?? 0)} <span className="text-fg-subtle">chk</span>265        <div className="text-[10.5px]">{s.changes_24h ?? 0} <span className="text-fg-subtle">chg</span></div>266      </Td>267      <Td mono>{fmtInt(s.snapshot_count ?? 0)}</Td>268      <Td mono>{nm !== null ? `${nm}%` : <span className="text-fg-subtle">—</span>}</Td>269      <Td mono className="whitespace-nowrap">{s.raw_changes ?? 0} / <span className="text-signal">{s.meaningful_changes ?? 0}</span></Td>270      <Td><Link href={`/sensor/${s.id}`} className="whitespace-nowrap text-[11px] text-info hover:underline">history →</Link></Td>271    </tr>272  );273}274